'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; import { projectsApi, assetsApi, invitationsApi, Project, Asset, Invitation, TranscodeStatus } from '@/lib/api'; import { useDropzone } from 'react-dropzone'; import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel'; async function safeCopy(text: string): Promise { if (typeof window === 'undefined') return; if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); } catch { /* ignore */ } } else { // Fallback: create a temp input so we can use execCommand on insecure contexts const el = document.createElement('textarea'); el.value = text; el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0'; document.body.appendChild(el); el.focus(); el.select(); try { document.execCommand('copy'); } catch { /* ignore */ } document.body.removeChild(el); } } const ROLE_COLORS: Record = { ADMIN: 'badge-danger', EDITOR: 'badge-brand', REVIEWER:'badge-muted', VIEWER: 'badge-subtle', }; const ROLE_LABELS: Record = { ADMIN: 'Admin', EDITOR: 'Editor', REVIEWER:'Reviewer', VIEWER: 'Viewer', }; export default function ProjectDetailPage() { const params = useParams(); const projectId = params.projectId as string; const { user, token } = useAuth(); const router = useRouter(); const [project, setProject] = useState(null); const [members, setMembers] = useState([]); const [pendingInvites, setPendingInvites] = useState([]); const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos'); // Invite form state (single shared form) const [inviteEmail, setInviteEmail] = useState(''); const [inviteRole, setInviteRole] = useState('REVIEWER'); const [inviting, setInviting] = useState(false); const [inviteError, setInviteError] = useState(''); const [inviteSuccess, setInviteSuccess] = useState(''); const [createdLink, setCreatedLink] = useState(''); // Edit member role const [editingRoleId, setEditingRoleId] = useState(null); const [editingRole, setEditingRole] = useState(''); const [updatingRole, setUpdatingRole] = useState(false); // Remove member const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null); const [removing, setRemoving] = useState(false); // Revoke invite const [revokingId, setRevokingId] = useState(null); // Copy link const [copiedInviteId, setCopiedInviteId] = useState(null); const [inviteUrlMap, setInviteUrlMap] = useState>({}); const canManage = members.some(m => m.user.id === user?.id && ['ADMIN', 'EDITOR'].includes(m.role) ); const isAdmin = members.some(m => m.user.id === user?.id && m.role === 'ADMIN' ); const loadAll = useCallback(async () => { if (!token) return; try { const [{ project: p }, { assets: a }] = await Promise.all([ projectsApi.get(token, projectId), assetsApi.list(token, projectId), ]); setProject(p); setMembers(p.members ?? []); setAssets(a); if (canManage) { const { invitations } = await invitationsApi.list(token, projectId); setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING')); } } catch { router.push('/projects'); } finally { setLoading(false); } }, [token, projectId, router, canManage]); useEffect(() => { loadAll(); }, [loadAll]); // ── Invite member ────────────────────────────────────────────────────────── const handleInvite = async (e: React.FormEvent) => { e.preventDefault(); if (!token || !inviteEmail.trim()) return; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail.trim())) { setInviteError('Invalid email address'); return; } setInviting(true); setInviteError(''); setInviteSuccess(''); setCreatedLink(''); try { const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole); const { invitations } = await invitationsApi.list(token, projectId); setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING')); setInviteUrlMap(prev => ({ ...prev, [inviteUrl.split('/').pop()!]: inviteUrl })); setInviteEmail(''); setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`); setTimeout(() => setInviteSuccess(''), 3000); } catch (err) { setInviteError(err instanceof Error ? err.message : 'Failed to send invitation'); } finally { setInviting(false); } }; // ── Create & copy link ───────────────────────────────────────────────────── const handleCreateLink = async () => { if (!token || !inviteEmail.trim()) return; setInviting(true); setInviteError(''); setInviteSuccess(''); setCreatedLink(''); try { const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole); const { invitations } = await invitationsApi.list(token, projectId); setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING')); // API returns full URL now (e.g. http://localhost:3000/invite/xxx) await safeCopy(inviteUrl); setCreatedLink(inviteUrl); setInviteEmail(''); } catch (err: any) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('already exists') || msg.includes('already a member') || msg.includes('409')) { setInviteError(`An invitation for "${inviteEmail.trim()}" is already pending or the user is already a member.`); } else { setInviteError(msg || 'Failed to create invitation link'); } } finally { setInviting(false); } }; // ── Change role ──────────────────────────────────────────────────────────── const handleChangeRole = async (memberId: string) => { if (!token || !editingRole) return; setUpdatingRole(true); try { await projectsApi.updateMember(token, projectId, memberId, editingRole); setMembers(prev => prev.map(m => m.id === memberId ? { ...m, role: editingRole } : m)); setEditingRoleId(null); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to update role'); } finally { setUpdatingRole(false); } }; // ── Remove member ───────────────────────────────────────────────────────── const handleRemoveMember = async () => { if (!token || !confirmRemove) return; setRemoving(true); try { await projectsApi.removeMember(token, projectId, confirmRemove.id); setMembers(prev => prev.filter(m => m.id !== confirmRemove!.id)); setConfirmRemove(null); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to remove member'); } finally { setRemoving(false); } }; // ── Revoke invite ────────────────────────────────────────────────────────── const handleRevoke = async (invitationId: string) => { if (!token) return; setRevokingId(invitationId); try { await invitationsApi.revoke(token, invitationId); setPendingInvites(prev => prev.filter(i => i.id !== invitationId)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to revoke invitation'); } finally { setRevokingId(null); } }; // ── Copy invite link ────────────────────────────────────────────────────── const handleCopyLink = async (invite: Invitation) => { const base = window.location.origin; const url = inviteUrlMap[invite.token] ?? `${base}/invite/${invite.token}`; await safeCopy(url); setCopiedInviteId(invite.id); setTimeout(() => setCopiedInviteId(null), 2000); }; const handleDrop = async (acceptedFiles: File[]) => { if (!token || acceptedFiles.length === 0) return; setUploading(true); for (const file of acceptedFiles) { const formData = new FormData(); formData.append('video', file); formData.append('projectId', projectId); formData.append('title', file.name.replace(/\.[^.]+$/, '')); try { const result = await assetsApi.upload(token, formData) as { asset: Asset }; setAssets(prev => [result.asset, ...prev]); } catch (err) { console.error('Upload failed:', err); alert(`Upload failed: ${file.name}`); } } setUploading(false); }; const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop: handleDrop, accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] }, multiple: true, disabled: uploading, }); const statusColors: Record = { PENDING_REVIEW: 'status-pending', CHANGES_REQUESTED: 'status-changes', APPROVED: 'status-approved', REJECTED: 'status-rejected', }; const statusLabels: Record = { PENDING_REVIEW: 'Pending', CHANGES_REQUESTED: 'Changes', APPROVED: 'Approved', REJECTED: 'Rejected', }; // ── Transcode status helpers ──────────────────────────────────────────────── const transcodeColors: Record = { PENDING: { text: '#94A3B8', dot: 'bg-slate-400', bg: 'rgba(148,163,184,0.10)' }, UPLOADING: { text: '#60A5FA', dot: 'bg-blue-400', bg: 'rgba(96,165,250,0.10)' }, PROCESSING: { text: '#A78BFA', dot: 'bg-violet-400', bg: 'rgba(167,139,250,0.10)' }, COMPLETED: { text: '#34D399', dot: 'bg-emerald-400', bg: 'rgba(52,211,153,0.10)' }, FAILED: { text: '#F87171', dot: 'bg-red-400', bg: 'rgba(248,113,113,0.10)' }, UNSUPPORTED_CODEC: { text: '#FBBF24', dot: 'bg-amber-400', bg: 'rgba(251,191,36,0.10)' }, }; const transcodeLabels: Record = { PENDING: 'Queued', UPLOADING: 'Uploading', PROCESSING: 'Processing', COMPLETED: 'Ready', FAILED: 'Failed', UNSUPPORTED_CODEC: 'Unsupported codec', }; // Poll for assets that are still processing const pollingRef = useRef | null>(null); // ── Delete asset ───────────────────────────────────────────────────────── const [confirmDelete, setConfirmDelete] = useState<{ id: string; title: string } | null>(null); const [deletingId, setDeletingId] = useState(null); const handleDeleteAsset = (id: string, title: string) => { setConfirmDelete({ id, title }); }; const confirmDeleteAsset = async () => { if (!token || !confirmDelete) return; setDeletingId(confirmDelete.id); try { await assetsApi.delete(token, confirmDelete.id); setAssets(prev => prev.filter(a => a.id !== confirmDelete.id)); setConfirmDelete(null); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to delete video'); } finally { setDeletingId(null); } }; useEffect(() => { const processingAssets = assets.filter(a => ['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus) ); if (processingAssets.length === 0) { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } return; } if (pollingRef.current) return; // already polling pollingRef.current = setInterval(async () => { if (!token) return; try { const { assets: updated } = await assetsApi.list(token, projectId); setAssets(updated); } catch {} }, 3000); return () => { if (pollingRef.current) clearInterval(pollingRef.current); }; }, [token, projectId, assets]); if (loading) { return (
Loading…
); } return (
{/* Header */}

{project?.name}

{canManage && ( {isAdmin ? 'Owner' : 'Editor'} )} {!canManage && !isAdmin && ( {members.find(m => m.user.id === user?.id)?.role ?? 'Member'} )}
{project?.description && (

{project.description}

)}
{/* Tabs */}
{[['videos', 'Videos', assets.length], ['transcode', 'Transcode Tasks', assets.filter(a => a.transcodeStatus !== 'COMPLETED').length], ['members', 'Members', members.length]].map(([tab, label, count]) => ( ))}
{assets.length} video{assets.length !== 1 ? 's' : ''}
{/* ── Videos Tab ───────────────────────────────────────────────────── */} {activeTab === 'videos' && ( <> {/* Upload zone — only shown to EDITOR and ADMIN */} {canManage ? (
{uploading ? (

Uploading…

) : ( <>

{isDragActive ? 'Drop videos here' : 'Drag & drop videos, or click to browse'}

MP4, MOV, WebM — up to 500MB each

)}
) : (

Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading videos.

)} {/* Asset grid */} {assets.length === 0 ? (

No videos yet

Upload your first video using the dropzone above

) : (
{assets.map((asset, i) => (
{/* Thumbnail */}
router.push(`/review/${asset.id}`)}> {/* Play overlay — only show when ready */} {asset.transcodeStatus === 'COMPLETED' && ( <> {asset.thumbnail ? ( {asset.title} ) : (
)}
)} {/* Not ready — show transcode status overlay */} {asset.transcodeStatus !== 'COMPLETED' && (
{/* Animated spinner */} {['UPLOADING', 'PROCESSING', 'PENDING'].includes(asset.transcodeStatus) && (
)} {/* Error icon */} {asset.transcodeStatus === 'FAILED' && (
)} {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
)} {/* Status label */} {transcodeLabels[asset.transcodeStatus]}
)} {/* Progress bar */} {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && (
)} {/* Duration badge */} {asset.duration && asset.transcodeStatus === 'COMPLETED' && ( {(() => { const m = Math.floor(asset.duration! / 60); const s = Math.floor(asset.duration! % 60); return `${m}:${s.toString().padStart(2,'0')}`; })()} )} {/* Codec badge */} {asset.codec && asset.transcodeStatus !== 'COMPLETED' && ( {asset.codec} )}
{/* Info */}

{asset.title}

{statusLabels[asset.status]}
{/* Transcode status row */} {asset.transcodeStatus !== 'COMPLETED' && (
{transcodeLabels[asset.transcodeStatus]} {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && asset.transcodeProgress > 0 ? ` — ${asset.transcodeProgress}%` : ''} {asset.transcodeStatus === 'FAILED' && asset.transcodeError && ( : {asset.transcodeError} )} {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && ( — will re-encode to H.264 )}
)}
{(asset as any)._count?.comments ?? 0} comment{((asset as any)._count?.comments ?? 0) !== 1 ? 's' : ''} {new Date(asset.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
{canManage && ( )}
))}
)} )} {/* ── Transcode Tasks Tab ─────────────────────────────────────────── */} {activeTab === 'transcode' && (
{ if (!token) return; try { await assetsApi.cancelTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, } : a)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); } }} />
)} {/* ── Members Tab ─────────────────────────────────────────────────── */} {activeTab === 'members' && (
{/* Invite form — single form, shared email + role */} {canManage && (

Invite someone

{ e.preventDefault(); handleInvite(e); }} className="flex items-end gap-3 flex-wrap" >
setInviteEmail(e.target.value)} placeholder="colleague@company.com" />
{/* Both buttons share the same email + role from this single form */}
{/* Created link feedback */} {createdLink && (
Link copied!

{createdLink}

)} {inviteError && (

{inviteError}

)} {inviteSuccess && (

{inviteSuccess}

)}
)} {/* Members list */}

Members ({members.length})

{members.length === 0 ? (

No members yet

) : (
{members.map(m => { const isMe = m.user.id === user?.id; const canEdit = isAdmin && !isMe; return (
{/* Avatar */}
{m.user.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
{/* Info */}
{m.user.name} {isMe && (you)}

{m.user.email}

{/* Joined date */} {new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} {/* Role */} {editingRoleId === m.id ? (
) : (
{ROLE_LABELS[m.role] ?? m.role} {canEdit && ( )} {isAdmin && !isMe && ( )}
)}
); })}
)}
{/* Pending invitations */} {canManage && (

Pending invitations

{pendingInvites.length}
{pendingInvites.length === 0 ? (

No pending invitations

) : (
{pendingInvites.map(inv => (
{/* Icon */}
{/* Info */}
{inv.email} {ROLE_LABELS[inv.role] ?? inv.role}
Sent {new Date(inv.createdAt).toLocaleDateString()} · Expires {new Date(inv.expiresAt).toLocaleDateString()}
{/* Actions */}
))}
)} {pendingInvites.length > 0 && (

Invitation links expire after 7 days. Copy the link and send it manually, or ask the recipient to check their email.

)}
)}
)}
{/* Delete asset confirm modal */} {confirmDelete && (

Delete video?

"{confirmDelete.title}"

This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.

)} {/* Remove member confirm modal */} {confirmRemove && (

Remove {confirmRemove.name}?

They'll lose access to this project and all its videos. They can rejoin if invited again.

)}
); }